Skip to content

chore: integrate storageService with tokenListController#7413

Merged
sahar-fehri merged 50 commits intomainfrom
chore/integrate-storage-service-with-tokenListController
Jan 26, 2026
Merged

chore: integrate storageService with tokenListController#7413
sahar-fehri merged 50 commits intomainfrom
chore/integrate-storage-service-with-tokenListController

Conversation

@sahar-fehri
Copy link
Contributor

@sahar-fehri sahar-fehri commented Dec 9, 2025

Description

Optimizes TokenListController storage to reduce write amplification by persisting tokensChainsCache via StorageService using per-chain files instead of a single monolithic state property.

Mobile: MetaMask/metamask-mobile#24019

Extension: MetaMask/metamask-extension#39250

Related: https://github.com/MetaMask/metamask-mobile/pull/22943/files

Related: https://github.com/MetaMask/decisions/pull/110

Related: #7192

Explanation

The tokensChainsCache (~5MB total, containing token lists for all chains) was persisted as part of the controller state. Every time a single chain's token list was updated (~100-500KB), the entire ~5MB cache was rewritten to disk, causing:

  • Startup performance issues (loading large state on app initialization)
  • Runtime performance degradation (frequent large writes during token fetches)
  • Impacts both extension

Solution

Per-Chain File Storage:
Each chain's cache is now stored in a separate file (e.g., tokensChainsCache:0x1, tokensChainsCache:0x89)
Only the updated chain (~100-500KB) is written on each token fetch, reducing write operations by ~90-95%
All chains are loaded in parallel at startup to maintain compatibility with TokenDetectionController
Key Changes:

  • Set tokensChainsCache metadata to persist: false to prevent framework-managed persistence
  • Added #loadCacheFromStorage() to load all per-chain files in parallel on initialization
  • Added #saveChainCacheToStorage(chainId) to persist only the specific chain that changed
  • Added #migrateStateToStorage() to automatically migrate existing cache data on first launch after upgrade
  • Updated clearingTokenListData() to remove all per-chain files

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Moves TokenListController token cache to per-chain persistence via StorageService, reducing write amplification and decoupling it from controller state.

  • BREAKING: tokensChainsCache is no longer state-persisted (persist: false); clients must call await controller.initialize() after construction
  • Per-chain storage keys (tokensChainsCache:<chainId>), parallel load on init, debounced persistence of changed chains only, and robust error handling
  • clearingTokenListData() is async and removes per-chain files; state updated accordingly
  • Keeps deprecated polling APIs but refactors internals; adds extensive tests for migration, persistence, and failure cases
  • Adds @metamask/storage-service dependency and tsconfig references; updates changelog

Written by Cursor Bugbot for commit 3da918d. This will update automatically on new commits. Configure here.

tokensChainsCache: {
includeInStateLogs: false,
persist: true,
persist: false, // Persisted separately via StorageService
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making this false to block disk writes

salimtb
salimtb previously approved these changes Dec 16, 2025
@sahar-fehri
Copy link
Contributor Author

@metamaskbot publish-preview

@github-actions
Copy link
Contributor

Preview builds have been published. See these instructions for more information about preview builds.

Expand for full list of packages and versions.
{
  "@metamask-previews/account-tree-controller": "4.0.0-preview-a94732c3",
  "@metamask-previews/accounts-controller": "35.0.2-preview-a94732c3",
  "@metamask-previews/address-book-controller": "7.0.1-preview-a94732c3",
  "@metamask-previews/ai-controllers": "0.0.0-preview-a94732c3",
  "@metamask-previews/analytics-controller": "1.0.0-preview-a94732c3",
  "@metamask-previews/announcement-controller": "8.0.0-preview-a94732c3",
  "@metamask-previews/app-metadata-controller": "2.0.0-preview-a94732c3",
  "@metamask-previews/approval-controller": "8.0.0-preview-a94732c3",
  "@metamask-previews/assets-controller": "0.0.0-preview-a94732c3",
  "@metamask-previews/assets-controllers": "96.0.0-preview-a94732c3",
  "@metamask-previews/base-controller": "9.0.0-preview-a94732c3",
  "@metamask-previews/bridge-controller": "64.8.1-preview-a94732c3",
  "@metamask-previews/bridge-status-controller": "64.4.4-preview-a94732c3",
  "@metamask-previews/build-utils": "3.0.4-preview-a94732c3",
  "@metamask-previews/chain-agnostic-permission": "1.4.0-preview-a94732c3",
  "@metamask-previews/claims-controller": "0.4.1-preview-a94732c3",
  "@metamask-previews/composable-controller": "12.0.0-preview-a94732c3",
  "@metamask-previews/connectivity-controller": "0.1.0-preview-a94732c3",
  "@metamask-previews/controller-utils": "11.18.0-preview-a94732c3",
  "@metamask-previews/core-backend": "5.0.0-preview-a94732c3",
  "@metamask-previews/delegation-controller": "2.0.0-preview-a94732c3",
  "@metamask-previews/earn-controller": "11.1.0-preview-a94732c3",
  "@metamask-previews/eip-5792-middleware": "2.1.0-preview-a94732c3",
  "@metamask-previews/eip-7702-internal-rpc-middleware": "0.1.0-preview-a94732c3",
  "@metamask-previews/eip1193-permission-middleware": "1.0.3-preview-a94732c3",
  "@metamask-previews/ens-controller": "19.0.2-preview-a94732c3",
  "@metamask-previews/error-reporting-service": "3.0.1-preview-a94732c3",
  "@metamask-previews/eth-block-tracker": "15.0.1-preview-a94732c3",
  "@metamask-previews/eth-json-rpc-middleware": "23.0.0-preview-a94732c3",
  "@metamask-previews/eth-json-rpc-provider": "6.0.0-preview-a94732c3",
  "@metamask-previews/foundryup": "1.0.1-preview-a94732c3",
  "@metamask-previews/gas-fee-controller": "26.0.2-preview-a94732c3",
  "@metamask-previews/gator-permissions-controller": "1.1.0-preview-a94732c3",
  "@metamask-previews/json-rpc-engine": "10.2.1-preview-a94732c3",
  "@metamask-previews/json-rpc-middleware-stream": "8.0.8-preview-a94732c3",
  "@metamask-previews/keyring-controller": "25.1.0-preview-a94732c3",
  "@metamask-previews/logging-controller": "7.0.1-preview-a94732c3",
  "@metamask-previews/message-manager": "14.1.0-preview-a94732c3",
  "@metamask-previews/messenger": "0.3.0-preview-a94732c3",
  "@metamask-previews/multichain-account-service": "5.1.0-preview-a94732c3",
  "@metamask-previews/multichain-api-middleware": "1.2.6-preview-a94732c3",
  "@metamask-previews/multichain-network-controller": "3.0.2-preview-a94732c3",
  "@metamask-previews/multichain-transactions-controller": "7.0.0-preview-a94732c3",
  "@metamask-previews/name-controller": "9.0.0-preview-a94732c3",
  "@metamask-previews/network-controller": "29.0.0-preview-a94732c3",
  "@metamask-previews/network-enablement-controller": "4.1.0-preview-a94732c3",
  "@metamask-previews/notification-services-controller": "21.0.0-preview-a94732c3",
  "@metamask-previews/permission-controller": "12.2.0-preview-a94732c3",
  "@metamask-previews/permission-log-controller": "5.0.0-preview-a94732c3",
  "@metamask-previews/perps-controller": "0.0.0-preview-a94732c3",
  "@metamask-previews/phishing-controller": "16.1.0-preview-a94732c3",
  "@metamask-previews/polling-controller": "16.0.2-preview-a94732c3",
  "@metamask-previews/preferences-controller": "22.0.0-preview-a94732c3",
  "@metamask-previews/profile-metrics-controller": "3.0.0-preview-a94732c3",
  "@metamask-previews/profile-sync-controller": "27.0.0-preview-a94732c3",
  "@metamask-previews/ramps-controller": "4.1.0-preview-a94732c3",
  "@metamask-previews/rate-limit-controller": "7.0.0-preview-a94732c3",
  "@metamask-previews/remote-feature-flag-controller": "4.0.0-preview-a94732c3",
  "@metamask-previews/sample-controllers": "4.0.2-preview-a94732c3",
  "@metamask-previews/seedless-onboarding-controller": "7.1.0-preview-a94732c3",
  "@metamask-previews/selected-network-controller": "26.0.2-preview-a94732c3",
  "@metamask-previews/shield-controller": "5.0.0-preview-a94732c3",
  "@metamask-previews/signature-controller": "39.0.1-preview-a94732c3",
  "@metamask-previews/storage-service": "0.0.1-preview-a94732c3",
  "@metamask-previews/subscription-controller": "5.4.0-preview-a94732c3",
  "@metamask-previews/token-search-discovery-controller": "4.0.0-preview-a94732c3",
  "@metamask-previews/transaction-controller": "62.9.2-preview-a94732c3",
  "@metamask-previews/transaction-pay-controller": "11.1.0-preview-a94732c3",
  "@metamask-previews/user-operation-controller": "41.0.2-preview-a94732c3"
}

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

@sahar-fehri sahar-fehri enabled auto-merge January 26, 2026 21:28
@sahar-fehri sahar-fehri added this pull request to the merge queue Jan 26, 2026
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Jan 26, 2026
@sahar-fehri sahar-fehri added this pull request to the merge queue Jan 26, 2026
Merged via the queue into main with commit e456ae6 Jan 26, 2026
592 of 597 checks passed
@sahar-fehri sahar-fehri deleted the chore/integrate-storage-service-with-tokenListController branch January 26, 2026 22:11
github-merge-queue bot pushed a commit to MetaMask/metamask-mobile that referenced this pull request Jan 28, 2026
## **Description**

Do not merge until this is released
MetaMask/core#7413

# Performance Comparison: Per-Chain Token Cache Storage

This PR implements per-chain file storage for `tokensChainsCache` in
`TokenListController`, replacing the single-file approach. Each chain's
token list is now stored in a separate file, reducing write
amplification during incremental updates.

---

## 📊 Complete Performance Comparison

### Cold Restart

| Metric | This PR | Main Branch |
|--------|---------|-------------|
| getAllPersistedState | 235ms | 288ms |
| TokenListController read | 0.04KB (shell only) | **4,102KB** |
| Cache load | 97ms (parallel reads) | **135ms** (single file) |
| Total overhead | ~332ms | ~288ms |

**Main is ~44ms faster on cold restart** (single file read vs parallel
reads + getAllKeys overhead)

---

### Onboarding

| Metric | This PR | Main Branch |
|--------|---------|-------------|
| Total data written | **4,070KB** | **9,472KB** |
| Number of writes | 7 (one per chain) | 5 (cumulative rewrites) |
| Total write time | ~38ms | ~118ms |

**This PR writes 57% less data and is 3x faster**

---

### Add New Chain (Monad)

| Metric | This PR | Main Branch |
|--------|---------|-------------|
| Data written | **33.79KB** | **4,103KB** |
| Time | **0.23ms** | **45.34ms** |

**This PR is 121x smaller and 197x faster!**

---

## Summary

| Category | This PR | Main Branch | Winner |
|----------|---------|-------------|--------|
| Cold restart | ~332ms | ~288ms | Main (+44ms) |
| Onboarding writes | 4,070KB | 9,472KB | **This PR (-57%)** |
| Onboarding time | ~38ms | ~118ms | **This PR (3x faster)** |
| Add chain writes | 33.79KB | 4,103KB | **This PR (-99%)** |
| Add chain time | 0.23ms | 45.34ms | **This PR (197x faster)** |
| Write amplification | None | Severe | **This PR** |

---

## 📋 Captured Logs

### This PR - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] TokenListController - 0.04KB - read: 89.00ms, parse: 0.00ms, total: 89.00ms
[ControllerStorage PERF] getAllPersistedState complete - 235.37ms

[StorageService PERF] getAllKeys TokenListController - 7 keys found - 277.37ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 96.19KB - read: 3.12ms, parse: 0.47ms, total: 3.59ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 1608.95KB - read: 30.86ms, parse: 10.57ms, total: 41.43ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1288.32KB - read: 48.95ms, parse: 21.65ms, total: 70.60ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 324.12KB - read: 72.62ms, parse: 5.21ms, total: 77.83ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 222.52KB - read: 77.90ms, parse: 7.06ms, total: 84.96ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 46.92KB - read: 85.16ms, parse: 0.82ms, total: 85.97ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 481.64KB - read: 88.85ms, parse: 8.74ms, total: 97.58ms
```

### This PR - Onboarding
```
[ControllerStorage PERF] getAllPersistedState complete - 731.91ms

[StorageService PERF] getAllKeys TokenListController - 0 keys found - 309.51ms
[StorageService PERF] getItem TokenListController:tokensChainsCache - NOT FOUND - 33.51ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 1610.14KB - stringify: 8.64ms, write: 8.54ms, total: 17.17ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 46.92KB - stringify: 0.19ms, write: 0.08ms, total: 0.26ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 481.34KB - stringify: 1.50ms, write: 2.45ms, total: 3.96ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 222.53KB - stringify: 1.03ms, write: 0.52ms, total: 1.56ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1288.32KB - stringify: 4.74ms, write: 6.49ms, total: 11.23ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 96.19KB - stringify: 0.31ms, write: 0.52ms, total: 0.83ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 324.46KB - stringify: 1.10ms, write: 1.72ms, total: 2.82ms
```

### This PR - Add New Chain (Monad)
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 33.79KB - stringify: 0.17ms, write: 0.07ms, total: 0.23ms
```

### Main Branch - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] TokenListController - 4102.55KB - read: 112.51ms, parse: 22.77ms, total: 135.27ms
[ControllerStorage PERF] getAllPersistedState complete - 288.21ms
```

### Main Branch - Onboarding
```
[ControllerStorage PERF] getAllPersistedState complete - 785.03ms

[ControllerStorage PERF] setItem TokenListController - 0.06KB - stringify: 0.00ms, write: 0.02ms, total: 0.02ms
[ControllerStorage PERF] setItem TokenListController - 1609.28KB - stringify: 13.41ms, write: 11.58ms, total: 24.99ms
[ControllerStorage PERF] setItem TokenListController - 1656.21KB - stringify: 12.85ms, write: 12.20ms, total: 25.04ms
[ControllerStorage PERF] setItem TokenListController - 2137.56KB - stringify: 12.47ms, write: 11.40ms, total: 23.87ms
[ControllerStorage PERF] setItem TokenListController - 4068.75KB - stringify: 22.00ms, write: 22.62ms, total: 44.62ms
```

### Main Branch - Add New Chain (Monad)
```
[ControllerStorage PERF] setItem TokenListController - 4102.55KB - stringify: 23.52ms, write: 21.82ms, total: 45.34ms
```

---

## 🔧 Performance Logging Code (Main Branch)

The following code was added to `app/store/persistConfig/index.ts` to
capture performance metrics:

### Read Performance Logging (getAllPersistedState)
```typescript
async getAllPersistedState(): Promise<Record<string, unknown>> {
  // eslint-disable-next-line no-console
  console.warn('[ControllerStorage PERF] getAllPersistedState started');
  const totalStart = performance.now();
  try {
    const backgroundState: Record<string, unknown> = {};

    await Promise.all(
      Array.from(
        new Set(
          Array.from(BACKGROUND_STATE_CHANGE_EVENT_NAMES).map(
            (eventName) => eventName.split(':')[0],
          ),
        ),
      ).map(async (controllerName) => {
        const key = `persist:${controllerName}`;
        const startTime = performance.now();
        try {
          const data = await FilesystemStorage.getItem(key);
          if (data) {
            const parseStart = performance.now();
            const parsedData = JSON.parse(data);
            const parseDuration = performance.now() - parseStart;
            const totalDuration = performance.now() - startTime;

            // Log performance for TokenListController specifically
            if (controllerName === 'TokenListController') {
              const sizeKB = (data.length / 1024).toFixed(2);
              // eslint-disable-next-line no-console
              console.warn(
                `[ControllerStorage PERF] ${controllerName} - ${sizeKB}KB - ` +
                  `read: ${(totalDuration - parseDuration).toFixed(2)}ms, ` +
                  `parse: ${parseDuration.toFixed(2)}ms, ` +
                  `total: ${totalDuration.toFixed(2)}ms`,
              );
            }
            // ... rest of the function
          }
        } catch (error) {
          // error handling
        }
      }),
    );

    const totalDuration = performance.now() - totalStart;
    // eslint-disable-next-line no-console
    console.warn(
      `[ControllerStorage PERF] getAllPersistedState complete - ${totalDuration.toFixed(2)}ms`,
    );

    return { backgroundState };
  } catch (error) {
    // error handling
  }
}
```

### Write Performance Logging (createPersistController)
```typescript
export const createPersistController = (debounceMs: number = 200) =>
  debounce(async (filteredState: unknown, controllerName: string) => {
    const startTime = performance.now();
    try {
      const stringifyStart = performance.now();
      const serialized = JSON.stringify(filteredState);
      const stringifyDuration = performance.now() - stringifyStart;

      await ControllerStorage.setItem(`persist:${controllerName}`, serialized);

      const totalDuration = performance.now() - startTime;
      if (controllerName === 'TokenListController') {
        const sizeKB = (serialized.length / 1024).toFixed(2);
        // eslint-disable-next-line no-console
        console.warn(
          `[ControllerStorage PERF] setItem ${controllerName} - ${sizeKB}KB - ` +
            `stringify: ${stringifyDuration.toFixed(2)}ms, ` +
            `write: ${(totalDuration - stringifyDuration).toFixed(2)}ms, ` +
            `total: ${totalDuration.toFixed(2)}ms`,
        );
      }
      Logger.log(`${controllerName} state persisted successfully`);
    } catch (error) {
      // error handling
    }
  }, debounceMs);
```

---

## 🔧 Performance Logging Code (This PR)

The following code was added to
`app/core/Engine/controllers/storage-service-init.ts` to capture
performance metrics for the per-chain storage:

### getItem - Read Performance Logging
```typescript
async getItem(namespace: string, key: string): Promise<StorageGetResult> {
  // eslint-disable-next-line no-console
  console.warn(`[StorageService DEBUG] getItem called: ${namespace}:${key}`);
  const startTime = performance.now();
  try {
    const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;
    const serialized = await FilesystemStorage.getItem(fullKey);

    // Key not found - return empty object
    if (serialized === undefined || serialized === null) {
      const duration = performance.now() - startTime;
      if (
        key.includes('token') ||
        key.includes('Token') ||
        namespace.includes('Token')
      ) {
        // eslint-disable-next-line no-console
        console.warn(
          `[StorageService PERF] getItem ${namespace}:${key} - NOT FOUND - ${duration.toFixed(2)}ms`,
        );
      }
      return {};
    }

    const parseStart = performance.now();
    const result = JSON.parse(serialized) as Json;
    const parseDuration = performance.now() - parseStart;
    const totalDuration = performance.now() - startTime;

    if (
      key.includes('token') ||
      key.includes('Token') ||
      namespace.includes('Token')
    ) {
      const sizeKB = (serialized.length / 1024).toFixed(2);
      // eslint-disable-next-line no-console
      console.warn(
        `[StorageService PERF] getItem ${namespace}:${key} - ${sizeKB}KB - ` +
          `read: ${(totalDuration - parseDuration).toFixed(2)}ms, ` +
          `parse: ${parseDuration.toFixed(2)}ms, ` +
          `total: ${totalDuration.toFixed(2)}ms`,
      );
    }

    return { result };
  } catch (error) {
    // error handling
  }
}
```

### setItem - Write Performance Logging
```typescript
async setItem(namespace: string, key: string, value: Json): Promise<void> {
  // eslint-disable-next-line no-console
  console.warn(`[StorageService DEBUG] setItem called: ${namespace}:${key}`);
  const startTime = performance.now();
  try {
    const fullKey = `${STORAGE_KEY_PREFIX}${namespace}:${key}`;

    const stringifyStart = performance.now();
    const serialized = JSON.stringify(value);
    const stringifyDuration = performance.now() - stringifyStart;

    await FilesystemStorage.setItem(fullKey, serialized, Device.isIos());

    const totalDuration = performance.now() - startTime;

    if (
      key.includes('token') ||
      key.includes('Token') ||
      namespace.includes('Token')
    ) {
      const sizeKB = (serialized.length / 1024).toFixed(2);
      // eslint-disable-next-line no-console
      console.warn(
        `[StorageService PERF] setItem ${namespace}:${key} - ${sizeKB}KB - ` +
          `stringify: ${stringifyDuration.toFixed(2)}ms, ` +
          `write: ${(totalDuration - stringifyDuration).toFixed(2)}ms, ` +
          `total: ${totalDuration.toFixed(2)}ms`,
      );
    }
  } catch (error) {
    // error handling
  }
}
```

### getAllKeys - Key Enumeration Logging
```typescript
async getAllKeys(namespace: string): Promise<string[]> {
  // eslint-disable-next-line no-console
  console.warn(`[StorageService DEBUG] getAllKeys called: ${namespace}`);
  const startTime = performance.now();
  try {
    const allKeys = await FilesystemStorage.getAllKeys();

    if (!allKeys) {
      const duration = performance.now() - startTime;
      if (namespace.includes('Token')) {
        // eslint-disable-next-line no-console
        console.warn(
          `[StorageService PERF] getAllKeys ${namespace} - 0 keys - ${duration.toFixed(2)}ms`,
        );
      }
      return [];
    }

    const prefix = `${STORAGE_KEY_PREFIX}${namespace}:`;
    const filteredKeys = allKeys
      .filter((key) => key.startsWith(prefix))
      .map((key) => key.slice(prefix.length));

    const duration = performance.now() - startTime;
    if (namespace.includes('Token')) {
      // eslint-disable-next-line no-console
      console.warn(
        `[StorageService PERF] getAllKeys ${namespace} - ${filteredKeys.length} keys found - ${duration.toFixed(2)}ms`,
      );
    }

    return filteredKeys;
  } catch (error) {
    // error handling
  }
}
```




## **Changelog**

CHANGELOG entry: integrates per chain file save for tokenListController.

## **Related issues**

Related: MetaMask/core#7413

## **Manual testing steps**

```gherkin
Feature: my feature name

  Scenario: user [verb for user action]
    Given [describe expected initial app state]

    When user [verb for user action]
    Then [describe expected outcome]
```

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces per-chain StorageService-backed persistence for
`TokenListController` and migrates existing cache data.
> 
> - Adds migration `114` to move `TokenListController.tokensChainsCache`
from Redux state to per-chain filesystem keys
(`storageService:TokenListController:tokensChainsCache:{chainId}`),
avoids overwrites, handles errors, and clears in-state cache; includes
comprehensive tests
> - Expands `TokenListController` messenger to allow
`StorageService:getAllKeys|getItem|setItem|removeItem`
> - Updates `token-list-controller-init` to pass persisted state,
subscribe to network changes, and call `controller.initialize()`; adds
tests mocking controller and verifying initialize
> - Bumps `@metamask/assets-controllers` to `^98.0.0` and registers
migration in `migrations/index.ts`
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
385b6a3. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
github-merge-queue bot pushed a commit to MetaMask/metamask-extension that referenced this pull request Jan 29, 2026
Do not merge before this gets in
MetaMask/core#7413

## **Description**
# Performance Comparison: Per-Chain Token Cache Storage

This PR implements per-chain file storage for `tokensChainsCache` in
`TokenListController`, replacing the single-file approach. Each chain's
token list is now stored in a separate file via StorageService, reducing
write amplification during incremental updates.

---

## 📊 Summary

| Category | This PR | Main Branch | Improvement |
|----------|---------|-------------|-------------|
| Cold restart (getAllPersistedState) | **216.20ms** | **374.30ms** |
**42% faster** |
| Cold restart (cache load) | **~310ms** (parallel) | Included in state
| ----- |
| Onboarding - Token cache | **4.40MB** (StorageService) | **4.40MB**
(in state) | Stored separately |
| Onboarding - Background saves | **~23MB** each | **~28MB** each |
**~5MB less per save** |
| Onboarding - Token cache in saves | ❌ No | ✅ Yes | **Eliminated** |
| **Add Monad** | **39KB** (new chain only) | **~4.4MB** (full cache
rewritten) | **Only new chain** |
| **Add Avalanche** | **157KB** (new chain only) | **~4.6MB** (full
cache rewritten) | **Only new chain** |
| **Background saves (idle)** | **~23MB** | **~28MB** | **~18% smaller**
|
| **TokenListController in state** | **0.04KB** | **4,601KB** | **Cache
moved out** |

**Key Results**:
1. **Adding a single chain on main branch** triggers a state save that
rewrites all TokenListController cache (~4.6MB) plus all other
controllers (~23MB) = ~28MB total.
2. **This PR writes ONLY the new chain** (e.g., 39KB for Monad) to
StorageService. The full token cache is NOT rewritten.
3. **Background saves are ~5MB smaller** (~23MB vs ~28MB) because the
token cache is stored separately.

---

## 🧪 Test Scenarios

### Scenario 1: Cold Restart (Existing User)

**Setup**: Extension with 10 networks cached (8 popular + Monad +
Avalanche), then browser restart.

#### This PR

**Controller Storage:**
```
[ControllerStorage PERF] getAllPersistedState complete - 216.20ms
```

**StorageService - getAllKeys:**
```
[StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms
```

**StorageService - Per-chain getItem (parallel reads):**
| Chain | Size | Read Time |
|-------|------|-----------|
| 0x1 (Ethereum) | 2022.39KB | 80.40ms |
| 0x38 (BSC) | 1045.19KB | 125.90ms |
| 0x2105 (Base) | 436.73KB | 97.20ms |
| 0x89 (Polygon) | 388.91KB | 128.30ms |
| 0xa4b1 (Arbitrum) | 345.79KB | 159.50ms |
| 0xa86a (Avalanche) | 156.90KB | 161.60ms |
| 0xa (Optimism) | 125.76KB | 132.20ms |
| 0xe708 (Linea) | 40.13KB | 163.20ms |
| 0x8f (Monad) | 39.15KB | 131.80ms |
| 0xaa36a7 (Sepolia) | 0.04KB | 162.70ms |

**Total cache size: ~4.60MB across 10 chains**

#### Main Branch

**Cold restart with 10 networks cached (8 popular + Monad +
Avalanche):**
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
```

#### Comparison

| Metric | This PR | Main Branch | Improvement |
|--------|---------|-------------|-------------|
| **getAllPersistedState** | **216.20ms** | **374.30ms** | **42%
faster** |
| TokenListController in state | **0.04KB** | **4,601KB** | Moved to
StorageService |
| StorageService cache load | **~163ms** (10 chains parallel) | N/A |
Separate loading |
| Total chains | 10 | 10 | Same |
| Total state size | ~23MB | ~28MB | **~18% smaller** |

**Key insight**: The main state loads **158ms faster** on this PR
because it's ~5MB smaller. The token cache is loaded separately via
StorageService in parallel during controller initialization.

---

### Scenario 2: Fresh Onboarding

**Setup**: Fresh wallet creation, enable all popular networks, wait for
token lists to fetch.

#### This PR

**Initial state (fresh install):**
```
[ControllerStorage PERF] getAllPersistedState complete - 13.70ms
[StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms
```

**Per-chain writes as networks are enabled:**
| Chain | Size | Stringify | Write | Total |
|-------|------|-----------|-------|-------|
| 0xaa36a7 (Sepolia) | 0.04KB | 0.00ms | 12.00ms | 12.00ms |
| 0xe708 (Linea) | 40.13KB | 0.00ms | 81.70ms | 81.70ms |
| 0xa (Optimism) | 125.76KB | 0.20ms | 81.20ms | 81.40ms |
| 0x2105 (Base) | 436.73KB | 0.80ms | 79.10ms | 79.90ms |
| 0x89 (Polygon) | 388.91KB | 0.80ms | 72.80ms | 73.60ms |
| 0xa4b1 (Arbitrum) | 345.79KB | 0.80ms | 67.10ms | 67.90ms |
| 0x1 (Ethereum) | 2022.39KB | 4.20ms | 68.40ms | 72.60ms |
| 0x38 (BSC) | 1045.19KB | 1.90ms | 37.80ms | 39.70ms |

**Summary:**
- **Total data written**: ~4.40MB (8 individual writes)
- **Number of writes**: 8 (one per chain)
- **Total write time**: ~509ms (sum of individual writes)

#### Main Branch

**Initial state (fresh install):**
```
[ControllerStorage PERF] getAllPersistedState complete - 14.60ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms
```

**After clicking "All Popular Networks":**
```
[ControllerStorage PERF] set() - TokenListController size: 4405.07KB
[ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms
```

**Summary:**
- **TokenListController size**: 4,405KB (~4.4MB) - cached in controller
state
- **Total state written**: 27,830KB (~27.2MB) - entire MetaMask state
- **Write time**: 526.70ms

#### Comparison

| Metric | This PR | Main Branch | Difference |
|--------|---------|-------------|------------|
| Token cache data | **4.40MB** | **4.40MB** | Same amount |
| Token cache location | **StorageService** (separate) | **Main state**
| Separated |
| Background save size | **~23MB** | **~28MB** | **~5MB smaller** |
| Token cache in every save | ❌ **No** | ✅ **Yes** | **Eliminated** |

**Key insight**: Both branches have continuous background saves. On main
branch, every save includes the ~4.4MB token cache. On this PR, the
token cache is stored separately via StorageService, making each
background save ~5MB smaller.

---

### Scenario 3: Add New Chain

**Setup**: Existing wallet with cached networks, add a new network.

#### This PR

**Avalanche (0xa86a)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.80ms, write: 137.20ms, total: 138.00ms
```

**Monad (0x8f)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.00ms, write: 2.40ms, total: 2.40ms
```

**zkSync Era (0x144)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x144 - 12.98KB - stringify: 0.00ms, write: 0.90ms, total: 0.90ms
```

**Polygon (0x89)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.90ms, write: 15.40ms, total: 16.30ms
```

#### Main Branch

**Adding Monad (0x8f) to existing cache:**
```
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms
```

**Adding Avalanche (0xa86a) to existing cache:**
```
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

**Note**: Each chain addition triggers a full state save that includes
ALL TokenListController cache (~4.6MB) plus ALL other controllers
(~23MB) = ~28MB total.

#### Comparison

**Token cache write for new chain:**
| Chain | This PR | Main Branch | Difference |
|-------|---------|-------------|------------|
| **Monad (39KB)** | 39KB to StorageService | Full ~4.4MB cache
rewritten | **Only new chain written** |
| **Avalanche (157KB)** | 157KB to StorageService | Full ~4.6MB cache
rewritten | **Only new chain written** |

**Total state save triggered:**
| Metric | This PR | Main Branch | Difference |
|--------|---------|-------------|------------|
| State size | ~23MB (no cache) | ~28MB (with cache) | **~5MB smaller**
|
| Token cache included | ❌ No | ✅ Yes | **Separated** |
| New chain write | **39-157KB** (separate file) | Included in 28MB |
**Isolated** |

---

## 📋 Raw Logs

### This PR - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 216.20ms
[StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - read: 80.40ms, total: 80.40ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - read: 97.20ms, total: 97.20ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - read: 125.80ms, total: 125.90ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 388.91KB - read: 128.30ms, total: 128.30ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x8f - 39.15KB - read: 131.80ms, total: 131.80ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 125.76KB - read: 132.20ms, total: 132.20ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - read: 159.50ms, total: 159.50ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - read: 161.60ms, total: 161.60ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - read: 162.70ms, total: 162.70ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - read: 163.20ms, total: 163.20ms
```

### This PR - Onboarding
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 13.70ms
[StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - stringify: 0.00ms, write: 12.00ms, total: 12.00ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - stringify: 0.00ms, write: 81.70ms, total: 81.70ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 125.76KB - stringify: 0.20ms, write: 81.20ms, total: 81.40ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - stringify: 0.80ms, write: 79.10ms, total: 79.90ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.80ms, write: 72.80ms, total: 73.60ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - stringify: 0.80ms, write: 67.10ms, total: 67.90ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - stringify: 4.20ms, write: 68.40ms, total: 72.60ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - stringify: 1.90ms, write: 37.80ms, total: 39.70ms
```

### This PR - Add New Chain
```
# Adding Monad (39KB)
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.10ms, write: 1.60ms, total: 1.70ms
[ControllerStorage PERF] set() - TokenListController size: 0.04KB
[ControllerStorage PERF] set() complete - 23433.75KB - 418.90ms

# Adding Avalanche (157KB)
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.50ms, write: 5.30ms, total: 5.80ms
[ControllerStorage PERF] set() - TokenListController size: 0.04KB
[ControllerStorage PERF] set() complete - 23435.80KB - 416.10ms
```

### Main Branch - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28033.23KB - 426.60ms
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28033.01KB - 564.00ms
```

### Main Branch - Onboarding
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 14.60ms
[ControllerStorage PERF] set() complete - 0.06KB - 0.30ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.30KB - 130.20ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.67KB - 136.10ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.97KB - 136.50ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9955.72KB - 142.20ms
[ControllerStorage PERF] set() - TokenListController size: 4405.07KB
[ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms
```

### Main Branch - Add New Chain (Monad + Avalanche)
```
# Adding Monad (39KB)
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms

# Adding Avalanche (157KB)
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

---

## 🔧 How Performance Was Measured

Performance logging was added to:

1. **BrowserStorageAdapter**
(`app/scripts/lib/stores/browser-storage-adapter.ts`)
   - Logs for `getItem`, `setItem`, `getAllKeys` operations
   - Measures read time, stringify time, write time, and data size

2. **ExtensionStore** (`app/scripts/lib/stores/extension-store.ts`)
   - Logs for `getAllPersistedState` and controller state writes
   - Measures TokenListController-specific read/write performance

To enable logging, set `PERF_LOGGING_ENABLED = true` in both files.

---

## 📝 Logging Code Reference (Main Branch)

The following code was added to `extension-store.ts` on main branch to
capture performance metrics:

### Helper Functions (add at top of file after imports)

```typescript
// ============ PERF LOGGING (for testing) ============
const PERF_LOGGING_ENABLED = true;

function getSizeKB(obj: unknown): string {
  try {
    const str = JSON.stringify(obj);
    return (str.length / 1024).toFixed(2);
  } catch {
    return 'N/A';
  }
}

function logControllerReadPerf(
  controllerName: string,
  data: unknown,
  timeMs: number,
): void {
  if (!PERF_LOGGING_ENABLED) {
    return;
  }
  const sizeKB = getSizeKB(data);
  console.warn(
    `[ControllerStorage PERF] ${controllerName} read - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`,
  );
}

function logControllerWritePerf(
  controllerName: string,
  data: unknown,
  timeMs: number,
): void {
  if (!PERF_LOGGING_ENABLED) {
    return;
  }
  const sizeKB = getSizeKB(data);
  console.warn(
    `[ControllerStorage PERF] ${controllerName} write - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`,
  );
}
// ============ END PERF LOGGING ============
```

### In `get()` method - Add at start of method:

```typescript
const perfStart = performance.now();
if (PERF_LOGGING_ENABLED) {
  console.warn('[ControllerStorage PERF] getAllPersistedState started');
}
```

### In `get()` method - Add after data is loaded:

```typescript
// PERF: Log overall time and TokenListController size
if (PERF_LOGGING_ENABLED) {
  const elapsed = performance.now() - perfStart;
  console.warn(
    `[ControllerStorage PERF] getAllPersistedState complete - ${elapsed.toFixed(2)}ms`,
  );
  // Log TokenListController state size specifically
  if (data.TokenListController) {
    logControllerReadPerf(
      'TokenListController',
      data.TokenListController,
      elapsed,
    );
  }
}
```

### In `set()` method - Add logging:

```typescript
const perfStart = performance.now();

// PERF: Log TokenListController size before write
if (
  PERF_LOGGING_ENABLED &&
  isObject(data) &&
  hasProperty(data, 'TokenListController')
) {
  const tlcSize = getSizeKB(data.TokenListController);
  console.warn(
    `[ControllerStorage PERF] set() - TokenListController size: ${tlcSize}KB`,
  );
}

// ... existing set logic ...

// PERF: Log total write time (add after await local.set())
if (PERF_LOGGING_ENABLED) {
  const elapsed = performance.now() - perfStart;
  const totalSize = getSizeKB({ data, meta });
  console.warn(
    `[ControllerStorage PERF] set() complete - ${totalSize}KB - ${elapsed.toFixed(2)}ms`,
  );
}
```

### Expected Log Output

**Cold Restart:**
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
```

**Write (adding chain or background save):**
```
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

---

## 💡 Key Takeaways

1. **Write amplification eliminated**: Adding a single chain now writes
only that chain's data (~30-200KB) instead of the entire cache (~4MB)

2. **Faster incremental updates**: Per-chain writes are significantly
faster than full cache rewrites

3. **Cold restart trade-off**: Parallel file reads + getAllKeys adds
some overhead vs single file read, but the difference is minimal

4. **Onboarding improvement**: Total data written during onboarding is
reduced by avoiding cumulative rewrites

---

## ✅ PR Branch: Background Writes No Longer Include Token Cache

On this PR branch, background writes show:

```
[ControllerStorage PERF] set() - TokenListController size: 0.04KB    ← TINY! No cache!
[ControllerStorage PERF] set() complete - 23425.30KB - 481.60ms      ← ~23MB (not 28MB)
```

**Key proof**: TokenListController is only 0.04KB in the main state
because the ~4.4MB token cache is stored separately in StorageService.

---

## ⚠️ Main Branch: Continuous Background Write Amplification

During testing on main branch, we observed that the **entire 27.8MB
state is being rewritten repeatedly** even when the user is idle:

```
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 632.30ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 456.40ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 606.40ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.59KB - 625.10ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.59KB - 597.90ms
```

### Why This Happens

MetaMask has background processes that trigger state saves:
- Token balance polling
- Price updates
- Network status checks
- Account sync
- DeFi positions updates
- etc.

Each time ANY controller state changes, the **entire state** (~27.8MB)
is serialized and written to storage, including the **4.4MB
TokenListController cache that hasn't changed**.

### Impact Comparison

| Metric | This PR | Main Branch |
|--------|---------|-------------|
| State size per write | **~23MB** | **~28MB** |
| TokenListController in state | **0.04KB** | **4,601KB** |
| Token cache included in saves | ❌ No | ✅ Yes (every save) |
| Write time | ~480ms | ~550ms |

### How This PR Helps

By moving `tokensChainsCache` to StorageService:

1. **Background saves are ~18% smaller** (~23MB instead of ~28MB)
2. **Token cache only written when it actually changes** (new chain
added or cache refresh)
3. **Reduced disk I/O** - ~5MB less data serialized and written on every
background save
4. **Better SSD/storage health** - less unnecessary write cycles


## **Changelog**


CHANGELOG entry: No user facing changes; this only updates the storage
location for tokenListController.

## **Related issues**

Fixes:

## **Manual testing steps**

1. Go to this page...
2.
3.

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it changes how `TokenListController` persists
cached token lists and adds a state migration writing to
`browser.storage.local`, which could impact startup/migration behavior
if keys or storage operations fail.
> 
> **Overview**
> **Moves `TokenListController` token list caching to StorageService.**
The controller messenger now allows `StorageService:*` actions and
`TokenListControllerInit` fires `controller.initialize()` on startup to
load cached lists from storage (logging errors but not failing init).
> 
> **Adds migration #190 to preserve existing caches.** Migration `190`
copies `tokensChainsCache` entries into per-chain
`storageService:TokenListController:tokensChainsCache:{chainId}` keys
without overwriting existing entries, then clears the in-state cache and
bumps fixtures/snapshots to version `190`.
> 
> **Updates tests and deps for the new storage behavior.** Jest mocks
for `webextension-polyfill` are made async/shared across imports, e2e
state persistence ignores StorageService-prefixed keys, and
`@metamask/assets-controllers` is bumped to `^98.0.0`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
62be895. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
github-merge-queue bot pushed a commit to MetaMask/metamask-extension that referenced this pull request Jan 29, 2026
Do not merge before this gets in
MetaMask/core#7413

## **Description**
# Performance Comparison: Per-Chain Token Cache Storage

This PR implements per-chain file storage for `tokensChainsCache` in
`TokenListController`, replacing the single-file approach. Each chain's
token list is now stored in a separate file via StorageService, reducing
write amplification during incremental updates.

---

## 📊 Summary

| Category | This PR | Main Branch | Improvement |
|----------|---------|-------------|-------------|
| Cold restart (getAllPersistedState) | **216.20ms** | **374.30ms** |
**42% faster** |
| Cold restart (cache load) | **~310ms** (parallel) | Included in state
| ----- |
| Onboarding - Token cache | **4.40MB** (StorageService) | **4.40MB**
(in state) | Stored separately |
| Onboarding - Background saves | **~23MB** each | **~28MB** each |
**~5MB less per save** |
| Onboarding - Token cache in saves | ❌ No | ✅ Yes | **Eliminated** |
| **Add Monad** | **39KB** (new chain only) | **~4.4MB** (full cache
rewritten) | **Only new chain** |
| **Add Avalanche** | **157KB** (new chain only) | **~4.6MB** (full
cache rewritten) | **Only new chain** |
| **Background saves (idle)** | **~23MB** | **~28MB** | **~18% smaller**
|
| **TokenListController in state** | **0.04KB** | **4,601KB** | **Cache
moved out** |

**Key Results**:
1. **Adding a single chain on main branch** triggers a state save that
rewrites all TokenListController cache (~4.6MB) plus all other
controllers (~23MB) = ~28MB total.
2. **This PR writes ONLY the new chain** (e.g., 39KB for Monad) to
StorageService. The full token cache is NOT rewritten.
3. **Background saves are ~5MB smaller** (~23MB vs ~28MB) because the
token cache is stored separately.

---

## 🧪 Test Scenarios

### Scenario 1: Cold Restart (Existing User)

**Setup**: Extension with 10 networks cached (8 popular + Monad +
Avalanche), then browser restart.

#### This PR

**Controller Storage:**
```
[ControllerStorage PERF] getAllPersistedState complete - 216.20ms
```

**StorageService - getAllKeys:**
```
[StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms
```

**StorageService - Per-chain getItem (parallel reads):**
| Chain | Size | Read Time |
|-------|------|-----------|
| 0x1 (Ethereum) | 2022.39KB | 80.40ms |
| 0x38 (BSC) | 1045.19KB | 125.90ms |
| 0x2105 (Base) | 436.73KB | 97.20ms |
| 0x89 (Polygon) | 388.91KB | 128.30ms |
| 0xa4b1 (Arbitrum) | 345.79KB | 159.50ms |
| 0xa86a (Avalanche) | 156.90KB | 161.60ms |
| 0xa (Optimism) | 125.76KB | 132.20ms |
| 0xe708 (Linea) | 40.13KB | 163.20ms |
| 0x8f (Monad) | 39.15KB | 131.80ms |
| 0xaa36a7 (Sepolia) | 0.04KB | 162.70ms |

**Total cache size: ~4.60MB across 10 chains**

#### Main Branch

**Cold restart with 10 networks cached (8 popular + Monad +
Avalanche):**
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
```

#### Comparison

| Metric | This PR | Main Branch | Improvement |
|--------|---------|-------------|-------------|
| **getAllPersistedState** | **216.20ms** | **374.30ms** | **42%
faster** |
| TokenListController in state | **0.04KB** | **4,601KB** | Moved to
StorageService |
| StorageService cache load | **~163ms** (10 chains parallel) | N/A |
Separate loading |
| Total chains | 10 | 10 | Same |
| Total state size | ~23MB | ~28MB | **~18% smaller** |

**Key insight**: The main state loads **158ms faster** on this PR
because it's ~5MB smaller. The token cache is loaded separately via
StorageService in parallel during controller initialization.

---

### Scenario 2: Fresh Onboarding

**Setup**: Fresh wallet creation, enable all popular networks, wait for
token lists to fetch.

#### This PR

**Initial state (fresh install):**
```
[ControllerStorage PERF] getAllPersistedState complete - 13.70ms
[StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms
```

**Per-chain writes as networks are enabled:**
| Chain | Size | Stringify | Write | Total |
|-------|------|-----------|-------|-------|
| 0xaa36a7 (Sepolia) | 0.04KB | 0.00ms | 12.00ms | 12.00ms |
| 0xe708 (Linea) | 40.13KB | 0.00ms | 81.70ms | 81.70ms |
| 0xa (Optimism) | 125.76KB | 0.20ms | 81.20ms | 81.40ms |
| 0x2105 (Base) | 436.73KB | 0.80ms | 79.10ms | 79.90ms |
| 0x89 (Polygon) | 388.91KB | 0.80ms | 72.80ms | 73.60ms |
| 0xa4b1 (Arbitrum) | 345.79KB | 0.80ms | 67.10ms | 67.90ms |
| 0x1 (Ethereum) | 2022.39KB | 4.20ms | 68.40ms | 72.60ms |
| 0x38 (BSC) | 1045.19KB | 1.90ms | 37.80ms | 39.70ms |

**Summary:**
- **Total data written**: ~4.40MB (8 individual writes)
- **Number of writes**: 8 (one per chain)
- **Total write time**: ~509ms (sum of individual writes)

#### Main Branch

**Initial state (fresh install):**
```
[ControllerStorage PERF] getAllPersistedState complete - 14.60ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms
```

**After clicking "All Popular Networks":**
```
[ControllerStorage PERF] set() - TokenListController size: 4405.07KB
[ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms
```

**Summary:**
- **TokenListController size**: 4,405KB (~4.4MB) - cached in controller
state
- **Total state written**: 27,830KB (~27.2MB) - entire MetaMask state
- **Write time**: 526.70ms

#### Comparison

| Metric | This PR | Main Branch | Difference |
|--------|---------|-------------|------------|
| Token cache data | **4.40MB** | **4.40MB** | Same amount |
| Token cache location | **StorageService** (separate) | **Main state**
| Separated |
| Background save size | **~23MB** | **~28MB** | **~5MB smaller** |
| Token cache in every save | ❌ **No** | ✅ **Yes** | **Eliminated** |

**Key insight**: Both branches have continuous background saves. On main
branch, every save includes the ~4.4MB token cache. On this PR, the
token cache is stored separately via StorageService, making each
background save ~5MB smaller.

---

### Scenario 3: Add New Chain

**Setup**: Existing wallet with cached networks, add a new network.

#### This PR

**Avalanche (0xa86a)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.80ms, write: 137.20ms, total: 138.00ms
```

**Monad (0x8f)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.00ms, write: 2.40ms, total: 2.40ms
```

**zkSync Era (0x144)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x144 - 12.98KB - stringify: 0.00ms, write: 0.90ms, total: 0.90ms
```

**Polygon (0x89)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.90ms, write: 15.40ms, total: 16.30ms
```

#### Main Branch

**Adding Monad (0x8f) to existing cache:**
```
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms
```

**Adding Avalanche (0xa86a) to existing cache:**
```
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

**Note**: Each chain addition triggers a full state save that includes
ALL TokenListController cache (~4.6MB) plus ALL other controllers
(~23MB) = ~28MB total.

#### Comparison

**Token cache write for new chain:**
| Chain | This PR | Main Branch | Difference |
|-------|---------|-------------|------------|
| **Monad (39KB)** | 39KB to StorageService | Full ~4.4MB cache
rewritten | **Only new chain written** |
| **Avalanche (157KB)** | 157KB to StorageService | Full ~4.6MB cache
rewritten | **Only new chain written** |

**Total state save triggered:**
| Metric | This PR | Main Branch | Difference |
|--------|---------|-------------|------------|
| State size | ~23MB (no cache) | ~28MB (with cache) | **~5MB smaller**
|
| Token cache included | ❌ No | ✅ Yes | **Separated** |
| New chain write | **39-157KB** (separate file) | Included in 28MB |
**Isolated** |

---

## 📋 Raw Logs

### This PR - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 216.20ms
[StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - read: 80.40ms, total: 80.40ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - read: 97.20ms, total: 97.20ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - read: 125.80ms, total: 125.90ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 388.91KB - read: 128.30ms, total: 128.30ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x8f - 39.15KB - read: 131.80ms, total: 131.80ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 125.76KB - read: 132.20ms, total: 132.20ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - read: 159.50ms, total: 159.50ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - read: 161.60ms, total: 161.60ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - read: 162.70ms, total: 162.70ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - read: 163.20ms, total: 163.20ms
```

### This PR - Onboarding
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 13.70ms
[StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - stringify: 0.00ms, write: 12.00ms, total: 12.00ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - stringify: 0.00ms, write: 81.70ms, total: 81.70ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 125.76KB - stringify: 0.20ms, write: 81.20ms, total: 81.40ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - stringify: 0.80ms, write: 79.10ms, total: 79.90ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.80ms, write: 72.80ms, total: 73.60ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - stringify: 0.80ms, write: 67.10ms, total: 67.90ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - stringify: 4.20ms, write: 68.40ms, total: 72.60ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - stringify: 1.90ms, write: 37.80ms, total: 39.70ms
```

### This PR - Add New Chain
```
# Adding Monad (39KB)
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.10ms, write: 1.60ms, total: 1.70ms
[ControllerStorage PERF] set() - TokenListController size: 0.04KB
[ControllerStorage PERF] set() complete - 23433.75KB - 418.90ms

# Adding Avalanche (157KB)
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.50ms, write: 5.30ms, total: 5.80ms
[ControllerStorage PERF] set() - TokenListController size: 0.04KB
[ControllerStorage PERF] set() complete - 23435.80KB - 416.10ms
```

### Main Branch - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28033.23KB - 426.60ms
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28033.01KB - 564.00ms
```

### Main Branch - Onboarding
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 14.60ms
[ControllerStorage PERF] set() complete - 0.06KB - 0.30ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.30KB - 130.20ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.67KB - 136.10ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.97KB - 136.50ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9955.72KB - 142.20ms
[ControllerStorage PERF] set() - TokenListController size: 4405.07KB
[ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms
```

### Main Branch - Add New Chain (Monad + Avalanche)
```
# Adding Monad (39KB)
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms

# Adding Avalanche (157KB)
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

---

## 🔧 How Performance Was Measured

Performance logging was added to:

1. **BrowserStorageAdapter**
(`app/scripts/lib/stores/browser-storage-adapter.ts`)
   - Logs for `getItem`, `setItem`, `getAllKeys` operations
   - Measures read time, stringify time, write time, and data size

2. **ExtensionStore** (`app/scripts/lib/stores/extension-store.ts`)
   - Logs for `getAllPersistedState` and controller state writes
   - Measures TokenListController-specific read/write performance

To enable logging, set `PERF_LOGGING_ENABLED = true` in both files.

---

## 📝 Logging Code Reference (Main Branch)

The following code was added to `extension-store.ts` on main branch to
capture performance metrics:

### Helper Functions (add at top of file after imports)

```typescript
// ============ PERF LOGGING (for testing) ============
const PERF_LOGGING_ENABLED = true;

function getSizeKB(obj: unknown): string {
  try {
    const str = JSON.stringify(obj);
    return (str.length / 1024).toFixed(2);
  } catch {
    return 'N/A';
  }
}

function logControllerReadPerf(
  controllerName: string,
  data: unknown,
  timeMs: number,
): void {
  if (!PERF_LOGGING_ENABLED) {
    return;
  }
  const sizeKB = getSizeKB(data);
  console.warn(
    `[ControllerStorage PERF] ${controllerName} read - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`,
  );
}

function logControllerWritePerf(
  controllerName: string,
  data: unknown,
  timeMs: number,
): void {
  if (!PERF_LOGGING_ENABLED) {
    return;
  }
  const sizeKB = getSizeKB(data);
  console.warn(
    `[ControllerStorage PERF] ${controllerName} write - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`,
  );
}
// ============ END PERF LOGGING ============
```

### In `get()` method - Add at start of method:

```typescript
const perfStart = performance.now();
if (PERF_LOGGING_ENABLED) {
  console.warn('[ControllerStorage PERF] getAllPersistedState started');
}
```

### In `get()` method - Add after data is loaded:

```typescript
// PERF: Log overall time and TokenListController size
if (PERF_LOGGING_ENABLED) {
  const elapsed = performance.now() - perfStart;
  console.warn(
    `[ControllerStorage PERF] getAllPersistedState complete - ${elapsed.toFixed(2)}ms`,
  );
  // Log TokenListController state size specifically
  if (data.TokenListController) {
    logControllerReadPerf(
      'TokenListController',
      data.TokenListController,
      elapsed,
    );
  }
}
```

### In `set()` method - Add logging:

```typescript
const perfStart = performance.now();

// PERF: Log TokenListController size before write
if (
  PERF_LOGGING_ENABLED &&
  isObject(data) &&
  hasProperty(data, 'TokenListController')
) {
  const tlcSize = getSizeKB(data.TokenListController);
  console.warn(
    `[ControllerStorage PERF] set() - TokenListController size: ${tlcSize}KB`,
  );
}

// ... existing set logic ...

// PERF: Log total write time (add after await local.set())
if (PERF_LOGGING_ENABLED) {
  const elapsed = performance.now() - perfStart;
  const totalSize = getSizeKB({ data, meta });
  console.warn(
    `[ControllerStorage PERF] set() complete - ${totalSize}KB - ${elapsed.toFixed(2)}ms`,
  );
}
```

### Expected Log Output

**Cold Restart:**
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
```

**Write (adding chain or background save):**
```
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

---

## 💡 Key Takeaways

1. **Write amplification eliminated**: Adding a single chain now writes
only that chain's data (~30-200KB) instead of the entire cache (~4MB)

2. **Faster incremental updates**: Per-chain writes are significantly
faster than full cache rewrites

3. **Cold restart trade-off**: Parallel file reads + getAllKeys adds
some overhead vs single file read, but the difference is minimal

4. **Onboarding improvement**: Total data written during onboarding is
reduced by avoiding cumulative rewrites

---

## ✅ PR Branch: Background Writes No Longer Include Token Cache

On this PR branch, background writes show:

```
[ControllerStorage PERF] set() - TokenListController size: 0.04KB    ← TINY! No cache!
[ControllerStorage PERF] set() complete - 23425.30KB - 481.60ms      ← ~23MB (not 28MB)
```

**Key proof**: TokenListController is only 0.04KB in the main state
because the ~4.4MB token cache is stored separately in StorageService.

---

## ⚠️ Main Branch: Continuous Background Write Amplification

During testing on main branch, we observed that the **entire 27.8MB
state is being rewritten repeatedly** even when the user is idle:

```
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 632.30ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 456.40ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 606.40ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.59KB - 625.10ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.59KB - 597.90ms
```

### Why This Happens

MetaMask has background processes that trigger state saves:
- Token balance polling
- Price updates
- Network status checks
- Account sync
- DeFi positions updates
- etc.

Each time ANY controller state changes, the **entire state** (~27.8MB)
is serialized and written to storage, including the **4.4MB
TokenListController cache that hasn't changed**.

### Impact Comparison

| Metric | This PR | Main Branch |
|--------|---------|-------------|
| State size per write | **~23MB** | **~28MB** |
| TokenListController in state | **0.04KB** | **4,601KB** |
| Token cache included in saves | ❌ No | ✅ Yes (every save) |
| Write time | ~480ms | ~550ms |

### How This PR Helps

By moving `tokensChainsCache` to StorageService:

1. **Background saves are ~18% smaller** (~23MB instead of ~28MB)
2. **Token cache only written when it actually changes** (new chain
added or cache refresh)
3. **Reduced disk I/O** - ~5MB less data serialized and written on every
background save
4. **Better SSD/storage health** - less unnecessary write cycles


## **Changelog**


CHANGELOG entry: No user facing changes; this only updates the storage
location for tokenListController.

## **Related issues**

Fixes:

## **Manual testing steps**

1. Go to this page...
2.
3.

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it changes how `TokenListController` persists
cached token lists and adds a state migration writing to
`browser.storage.local`, which could impact startup/migration behavior
if keys or storage operations fail.
> 
> **Overview**
> **Moves `TokenListController` token list caching to StorageService.**
The controller messenger now allows `StorageService:*` actions and
`TokenListControllerInit` fires `controller.initialize()` on startup to
load cached lists from storage (logging errors but not failing init).
> 
> **Adds migration #190 to preserve existing caches.** Migration `190`
copies `tokensChainsCache` entries into per-chain
`storageService:TokenListController:tokensChainsCache:{chainId}` keys
without overwriting existing entries, then clears the in-state cache and
bumps fixtures/snapshots to version `190`.
> 
> **Updates tests and deps for the new storage behavior.** Jest mocks
for `webextension-polyfill` are made async/shared across imports, e2e
state persistence ignores StorageService-prefixed keys, and
`@metamask/assets-controllers` is bumped to `^98.0.0`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
62be895. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
github-merge-queue bot pushed a commit to MetaMask/metamask-extension that referenced this pull request Jan 29, 2026
Do not merge before this gets in
MetaMask/core#7413

## **Description**
# Performance Comparison: Per-Chain Token Cache Storage

This PR implements per-chain file storage for `tokensChainsCache` in
`TokenListController`, replacing the single-file approach. Each chain's
token list is now stored in a separate file via StorageService, reducing
write amplification during incremental updates.

---

## 📊 Summary

| Category | This PR | Main Branch | Improvement |
|----------|---------|-------------|-------------|
| Cold restart (getAllPersistedState) | **216.20ms** | **374.30ms** |
**42% faster** |
| Cold restart (cache load) | **~310ms** (parallel) | Included in state
| ----- |
| Onboarding - Token cache | **4.40MB** (StorageService) | **4.40MB**
(in state) | Stored separately |
| Onboarding - Background saves | **~23MB** each | **~28MB** each |
**~5MB less per save** |
| Onboarding - Token cache in saves | ❌ No | ✅ Yes | **Eliminated** |
| **Add Monad** | **39KB** (new chain only) | **~4.4MB** (full cache
rewritten) | **Only new chain** |
| **Add Avalanche** | **157KB** (new chain only) | **~4.6MB** (full
cache rewritten) | **Only new chain** |
| **Background saves (idle)** | **~23MB** | **~28MB** | **~18% smaller**
|
| **TokenListController in state** | **0.04KB** | **4,601KB** | **Cache
moved out** |

**Key Results**:
1. **Adding a single chain on main branch** triggers a state save that
rewrites all TokenListController cache (~4.6MB) plus all other
controllers (~23MB) = ~28MB total.
2. **This PR writes ONLY the new chain** (e.g., 39KB for Monad) to
StorageService. The full token cache is NOT rewritten.
3. **Background saves are ~5MB smaller** (~23MB vs ~28MB) because the
token cache is stored separately.

---

## 🧪 Test Scenarios

### Scenario 1: Cold Restart (Existing User)

**Setup**: Extension with 10 networks cached (8 popular + Monad +
Avalanche), then browser restart.

#### This PR

**Controller Storage:**
```
[ControllerStorage PERF] getAllPersistedState complete - 216.20ms
```

**StorageService - getAllKeys:**
```
[StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms
```

**StorageService - Per-chain getItem (parallel reads):**
| Chain | Size | Read Time |
|-------|------|-----------|
| 0x1 (Ethereum) | 2022.39KB | 80.40ms |
| 0x38 (BSC) | 1045.19KB | 125.90ms |
| 0x2105 (Base) | 436.73KB | 97.20ms |
| 0x89 (Polygon) | 388.91KB | 128.30ms |
| 0xa4b1 (Arbitrum) | 345.79KB | 159.50ms |
| 0xa86a (Avalanche) | 156.90KB | 161.60ms |
| 0xa (Optimism) | 125.76KB | 132.20ms |
| 0xe708 (Linea) | 40.13KB | 163.20ms |
| 0x8f (Monad) | 39.15KB | 131.80ms |
| 0xaa36a7 (Sepolia) | 0.04KB | 162.70ms |

**Total cache size: ~4.60MB across 10 chains**

#### Main Branch

**Cold restart with 10 networks cached (8 popular + Monad +
Avalanche):**
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
```

#### Comparison

| Metric | This PR | Main Branch | Improvement |
|--------|---------|-------------|-------------|
| **getAllPersistedState** | **216.20ms** | **374.30ms** | **42%
faster** |
| TokenListController in state | **0.04KB** | **4,601KB** | Moved to
StorageService |
| StorageService cache load | **~163ms** (10 chains parallel) | N/A |
Separate loading |
| Total chains | 10 | 10 | Same |
| Total state size | ~23MB | ~28MB | **~18% smaller** |

**Key insight**: The main state loads **158ms faster** on this PR
because it's ~5MB smaller. The token cache is loaded separately via
StorageService in parallel during controller initialization.

---

### Scenario 2: Fresh Onboarding

**Setup**: Fresh wallet creation, enable all popular networks, wait for
token lists to fetch.

#### This PR

**Initial state (fresh install):**
```
[ControllerStorage PERF] getAllPersistedState complete - 13.70ms
[StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms
```

**Per-chain writes as networks are enabled:**
| Chain | Size | Stringify | Write | Total |
|-------|------|-----------|-------|-------|
| 0xaa36a7 (Sepolia) | 0.04KB | 0.00ms | 12.00ms | 12.00ms |
| 0xe708 (Linea) | 40.13KB | 0.00ms | 81.70ms | 81.70ms |
| 0xa (Optimism) | 125.76KB | 0.20ms | 81.20ms | 81.40ms |
| 0x2105 (Base) | 436.73KB | 0.80ms | 79.10ms | 79.90ms |
| 0x89 (Polygon) | 388.91KB | 0.80ms | 72.80ms | 73.60ms |
| 0xa4b1 (Arbitrum) | 345.79KB | 0.80ms | 67.10ms | 67.90ms |
| 0x1 (Ethereum) | 2022.39KB | 4.20ms | 68.40ms | 72.60ms |
| 0x38 (BSC) | 1045.19KB | 1.90ms | 37.80ms | 39.70ms |

**Summary:**
- **Total data written**: ~4.40MB (8 individual writes)
- **Number of writes**: 8 (one per chain)
- **Total write time**: ~509ms (sum of individual writes)

#### Main Branch

**Initial state (fresh install):**
```
[ControllerStorage PERF] getAllPersistedState complete - 14.60ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms
```

**After clicking "All Popular Networks":**
```
[ControllerStorage PERF] set() - TokenListController size: 4405.07KB
[ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms
```

**Summary:**
- **TokenListController size**: 4,405KB (~4.4MB) - cached in controller
state
- **Total state written**: 27,830KB (~27.2MB) - entire MetaMask state
- **Write time**: 526.70ms

#### Comparison

| Metric | This PR | Main Branch | Difference |
|--------|---------|-------------|------------|
| Token cache data | **4.40MB** | **4.40MB** | Same amount |
| Token cache location | **StorageService** (separate) | **Main state**
| Separated |
| Background save size | **~23MB** | **~28MB** | **~5MB smaller** |
| Token cache in every save | ❌ **No** | ✅ **Yes** | **Eliminated** |

**Key insight**: Both branches have continuous background saves. On main
branch, every save includes the ~4.4MB token cache. On this PR, the
token cache is stored separately via StorageService, making each
background save ~5MB smaller.

---

### Scenario 3: Add New Chain

**Setup**: Existing wallet with cached networks, add a new network.

#### This PR

**Avalanche (0xa86a)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.80ms, write: 137.20ms, total: 138.00ms
```

**Monad (0x8f)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.00ms, write: 2.40ms, total: 2.40ms
```

**zkSync Era (0x144)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x144 - 12.98KB - stringify: 0.00ms, write: 0.90ms, total: 0.90ms
```

**Polygon (0x89)**:
```
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.90ms, write: 15.40ms, total: 16.30ms
```

#### Main Branch

**Adding Monad (0x8f) to existing cache:**
```
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms
```

**Adding Avalanche (0xa86a) to existing cache:**
```
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

**Note**: Each chain addition triggers a full state save that includes
ALL TokenListController cache (~4.6MB) plus ALL other controllers
(~23MB) = ~28MB total.

#### Comparison

**Token cache write for new chain:**
| Chain | This PR | Main Branch | Difference |
|-------|---------|-------------|------------|
| **Monad (39KB)** | 39KB to StorageService | Full ~4.4MB cache
rewritten | **Only new chain written** |
| **Avalanche (157KB)** | 157KB to StorageService | Full ~4.6MB cache
rewritten | **Only new chain written** |

**Total state save triggered:**
| Metric | This PR | Main Branch | Difference |
|--------|---------|-------------|------------|
| State size | ~23MB (no cache) | ~28MB (with cache) | **~5MB smaller**
|
| Token cache included | ❌ No | ✅ Yes | **Separated** |
| New chain write | **39-157KB** (separate file) | Included in 28MB |
**Isolated** |

---

## 📋 Raw Logs

### This PR - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 216.20ms
[StorageService PERF] getAllKeys TokenListController - 10 keys found - 277.60ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - read: 80.40ms, total: 80.40ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - read: 97.20ms, total: 97.20ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - read: 125.80ms, total: 125.90ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x89 - 388.91KB - read: 128.30ms, total: 128.30ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0x8f - 39.15KB - read: 131.80ms, total: 131.80ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa - 125.76KB - read: 132.20ms, total: 132.20ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - read: 159.50ms, total: 159.50ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - read: 161.60ms, total: 161.60ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - read: 162.70ms, total: 162.70ms
[StorageService PERF] getItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - read: 163.20ms, total: 163.20ms
```

### This PR - Onboarding
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 13.70ms
[StorageService PERF] getAllKeys TokenListController - 0 keys found - 23.50ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xaa36a7 - 0.04KB - stringify: 0.00ms, write: 12.00ms, total: 12.00ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xe708 - 40.13KB - stringify: 0.00ms, write: 81.70ms, total: 81.70ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa - 125.76KB - stringify: 0.20ms, write: 81.20ms, total: 81.40ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x2105 - 436.73KB - stringify: 0.80ms, write: 79.10ms, total: 79.90ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x89 - 388.91KB - stringify: 0.80ms, write: 72.80ms, total: 73.60ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa4b1 - 345.79KB - stringify: 0.80ms, write: 67.10ms, total: 67.90ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x1 - 2022.39KB - stringify: 4.20ms, write: 68.40ms, total: 72.60ms
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x38 - 1045.19KB - stringify: 1.90ms, write: 37.80ms, total: 39.70ms
```

### This PR - Add New Chain
```
# Adding Monad (39KB)
[StorageService PERF] setItem TokenListController:tokensChainsCache:0x8f - 39.15KB - stringify: 0.10ms, write: 1.60ms, total: 1.70ms
[ControllerStorage PERF] set() - TokenListController size: 0.04KB
[ControllerStorage PERF] set() complete - 23433.75KB - 418.90ms

# Adding Avalanche (157KB)
[StorageService PERF] setItem TokenListController:tokensChainsCache:0xa86a - 156.90KB - stringify: 0.50ms, write: 5.30ms, total: 5.80ms
[ControllerStorage PERF] set() - TokenListController size: 0.04KB
[ControllerStorage PERF] set() complete - 23435.80KB - 416.10ms
```

### Main Branch - Cold Restart
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28033.23KB - 426.60ms
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28033.01KB - 564.00ms
```

### Main Branch - Onboarding
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 14.60ms
[ControllerStorage PERF] set() complete - 0.06KB - 0.30ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.04KB - 129.10ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.30KB - 130.20ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.67KB - 136.10ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9952.97KB - 136.50ms
[ControllerStorage PERF] set() - TokenListController size: 0.06KB
[ControllerStorage PERF] set() complete - 9955.72KB - 142.20ms
[ControllerStorage PERF] set() - TokenListController size: 4405.07KB
[ControllerStorage PERF] set() complete - 27830.24KB - 526.70ms
```

### Main Branch - Add New Chain (Monad + Avalanche)
```
# Adding Monad (39KB)
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 569.00ms

# Adding Avalanche (157KB)
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

---

## 🔧 How Performance Was Measured

Performance logging was added to:

1. **BrowserStorageAdapter**
(`app/scripts/lib/stores/browser-storage-adapter.ts`)
   - Logs for `getItem`, `setItem`, `getAllKeys` operations
   - Measures read time, stringify time, write time, and data size

2. **ExtensionStore** (`app/scripts/lib/stores/extension-store.ts`)
   - Logs for `getAllPersistedState` and controller state writes
   - Measures TokenListController-specific read/write performance

To enable logging, set `PERF_LOGGING_ENABLED = true` in both files.

---

## 📝 Logging Code Reference (Main Branch)

The following code was added to `extension-store.ts` on main branch to
capture performance metrics:

### Helper Functions (add at top of file after imports)

```typescript
// ============ PERF LOGGING (for testing) ============
const PERF_LOGGING_ENABLED = true;

function getSizeKB(obj: unknown): string {
  try {
    const str = JSON.stringify(obj);
    return (str.length / 1024).toFixed(2);
  } catch {
    return 'N/A';
  }
}

function logControllerReadPerf(
  controllerName: string,
  data: unknown,
  timeMs: number,
): void {
  if (!PERF_LOGGING_ENABLED) {
    return;
  }
  const sizeKB = getSizeKB(data);
  console.warn(
    `[ControllerStorage PERF] ${controllerName} read - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`,
  );
}

function logControllerWritePerf(
  controllerName: string,
  data: unknown,
  timeMs: number,
): void {
  if (!PERF_LOGGING_ENABLED) {
    return;
  }
  const sizeKB = getSizeKB(data);
  console.warn(
    `[ControllerStorage PERF] ${controllerName} write - ${sizeKB}KB - ${timeMs.toFixed(2)}ms`,
  );
}
// ============ END PERF LOGGING ============
```

### In `get()` method - Add at start of method:

```typescript
const perfStart = performance.now();
if (PERF_LOGGING_ENABLED) {
  console.warn('[ControllerStorage PERF] getAllPersistedState started');
}
```

### In `get()` method - Add after data is loaded:

```typescript
// PERF: Log overall time and TokenListController size
if (PERF_LOGGING_ENABLED) {
  const elapsed = performance.now() - perfStart;
  console.warn(
    `[ControllerStorage PERF] getAllPersistedState complete - ${elapsed.toFixed(2)}ms`,
  );
  // Log TokenListController state size specifically
  if (data.TokenListController) {
    logControllerReadPerf(
      'TokenListController',
      data.TokenListController,
      elapsed,
    );
  }
}
```

### In `set()` method - Add logging:

```typescript
const perfStart = performance.now();

// PERF: Log TokenListController size before write
if (
  PERF_LOGGING_ENABLED &&
  isObject(data) &&
  hasProperty(data, 'TokenListController')
) {
  const tlcSize = getSizeKB(data.TokenListController);
  console.warn(
    `[ControllerStorage PERF] set() - TokenListController size: ${tlcSize}KB`,
  );
}

// ... existing set logic ...

// PERF: Log total write time (add after await local.set())
if (PERF_LOGGING_ENABLED) {
  const elapsed = performance.now() - perfStart;
  const totalSize = getSizeKB({ data, meta });
  console.warn(
    `[ControllerStorage PERF] set() complete - ${totalSize}KB - ${elapsed.toFixed(2)}ms`,
  );
}
```

### Expected Log Output

**Cold Restart:**
```
[ControllerStorage PERF] getAllPersistedState started
[ControllerStorage PERF] getAllPersistedState complete - 374.30ms
[ControllerStorage PERF] TokenListController read - 4601.13KB - 374.30ms
```

**Write (adding chain or background save):**
```
[ControllerStorage PERF] set() - TokenListController size: 4601.13KB
[ControllerStorage PERF] set() complete - 28036.55KB - 559.30ms
```

---

## 💡 Key Takeaways

1. **Write amplification eliminated**: Adding a single chain now writes
only that chain's data (~30-200KB) instead of the entire cache (~4MB)

2. **Faster incremental updates**: Per-chain writes are significantly
faster than full cache rewrites

3. **Cold restart trade-off**: Parallel file reads + getAllKeys adds
some overhead vs single file read, but the difference is minimal

4. **Onboarding improvement**: Total data written during onboarding is
reduced by avoiding cumulative rewrites

---

## ✅ PR Branch: Background Writes No Longer Include Token Cache

On this PR branch, background writes show:

```
[ControllerStorage PERF] set() - TokenListController size: 0.04KB    ← TINY! No cache!
[ControllerStorage PERF] set() complete - 23425.30KB - 481.60ms      ← ~23MB (not 28MB)
```

**Key proof**: TokenListController is only 0.04KB in the main state
because the ~4.4MB token cache is stored separately in StorageService.

---

## ⚠️ Main Branch: Continuous Background Write Amplification

During testing on main branch, we observed that the **entire 27.8MB
state is being rewritten repeatedly** even when the user is idle:

```
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 632.30ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 456.40ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.62KB - 606.40ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.59KB - 625.10ms
[ControllerStorage PERF] set() - TokenListController size: 4444.22KB
[ControllerStorage PERF] set() complete - 27877.59KB - 597.90ms
```

### Why This Happens

MetaMask has background processes that trigger state saves:
- Token balance polling
- Price updates
- Network status checks
- Account sync
- DeFi positions updates
- etc.

Each time ANY controller state changes, the **entire state** (~27.8MB)
is serialized and written to storage, including the **4.4MB
TokenListController cache that hasn't changed**.

### Impact Comparison

| Metric | This PR | Main Branch |
|--------|---------|-------------|
| State size per write | **~23MB** | **~28MB** |
| TokenListController in state | **0.04KB** | **4,601KB** |
| Token cache included in saves | ❌ No | ✅ Yes (every save) |
| Write time | ~480ms | ~550ms |

### How This PR Helps

By moving `tokensChainsCache` to StorageService:

1. **Background saves are ~18% smaller** (~23MB instead of ~28MB)
2. **Token cache only written when it actually changes** (new chain
added or cache refresh)
3. **Reduced disk I/O** - ~5MB less data serialized and written on every
background save
4. **Better SSD/storage health** - less unnecessary write cycles


## **Changelog**


CHANGELOG entry: No user facing changes; this only updates the storage
location for tokenListController.

## **Related issues**

Fixes:

## **Manual testing steps**

1. Go to this page...
2.
3.

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it changes how `TokenListController` persists
cached token lists and adds a state migration writing to
`browser.storage.local`, which could impact startup/migration behavior
if keys or storage operations fail.
> 
> **Overview**
> **Moves `TokenListController` token list caching to StorageService.**
The controller messenger now allows `StorageService:*` actions and
`TokenListControllerInit` fires `controller.initialize()` on startup to
load cached lists from storage (logging errors but not failing init).
> 
> **Adds migration #190 to preserve existing caches.** Migration `190`
copies `tokensChainsCache` entries into per-chain
`storageService:TokenListController:tokensChainsCache:{chainId}` keys
without overwriting existing entries, then clears the in-state cache and
bumps fixtures/snapshots to version `190`.
> 
> **Updates tests and deps for the new storage behavior.** Jest mocks
for `webextension-polyfill` are made async/shared across imports, e2e
state persistence ignores StorageService-prefixed keys, and
`@metamask/assets-controllers` is bumped to `^98.0.0`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
62be895. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: MetaMask Bot <metamaskbot@users.noreply.github.com>
github-merge-queue bot pushed a commit that referenced this pull request Jan 30, 2026
## Explanation

There was some complexity in handling the order of operations between
controller initialization and the event to persist state changes.

To simplify this, the controller has been updated such that it no longer
persists state changes until _after_ initialization. This makes the
logic easier to follow, and lets us delete an instance variable and a
few blocks of code.

The controller will be initialized as part of wallet initialization, so
it will not be "constructed but uninitialized" for any significant
length of time.

## References

Related to changes made in this PR:
#7413

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [x] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes when `TokenListController` starts persisting
`tokensChainsCache` and adjusts initialization/storage synchronization
logic; regressions could cause missed or unintended StorageService
writes/overwrites during startup.
> 
> **Overview**
> **`TokenListController` no longer subscribes to `stateChange` for
debounced StorageService persistence until `initialize()` is called**,
simplifying construction-time behavior.
> 
> Initialization/storage sync logic is simplified by removing the
“loaded-from-storage skip” tracking and by scheduling persistence for
any chains already present in state at init time. Tests are updated to
explicitly call `initialize()` before asserting persistence, and new
cases cover *no persistence before init* and *persisting updates that
occurred prior to init*.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6060063. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants